我在做一個叫 spectyn 的東西。目標是從一顆 agent 走到多 agent 集群,最後把整套接進自己的生活裡天天用。
寫了幾個月,累積下來大概是十幾萬行 Rust。但要往下一步走之前,我得先誠實回答一個問題:我手上這個東西,現在到底長什麼樣子?
我沒有從自己寫的設計文件開始查。文件會腐爛,而且腐爛的方式很難察覺——你不會收到通知,只會在某天發現照著文件做行不通。所以我從程式碼開始,一個模組一個模組地讀,把行數、呼叫關係、還有誰真的在執行路徑上,全部重新量一次。
量完之後,我把結果拿去對照五個我每天在用、或跟我做同一件事的專案:
opencode、codex、agy、claude——這四家就裝在我這台 Mac 上,所以我直接解剖它們的命令面,不引用文件hermes-agent(Nous Research)——沒裝,但我的 wrapper 腳本裡有它的介接紀錄,加上官方文件這篇是那次盤點的完整結果。結論先說:我缺的不是零件。真正的病是同一件事被我實作了兩次,而且常常只有一套是活的。
| 模組 | 行數 | 角色 |
|---|---|---|
bin/ |
20,490 | CLI 入口,61 個 subcommand |
tools/(33 檔) |
17,989 | 工具實作 |
skillbank/(46 檔) |
13,061 | 技能萃取/召回 |
serve.rs |
11,195 | HTTP daemon,96 條路由 |
tui.rs |
10,630 | 終端機介面 |
life_node/(25 檔) |
9,259 | 生活擷取 |
providers/(25 檔) |
8,727 | 供應商層 |
agent.rs |
4,174 | agent 迴圈 |
governed_run/ |
3,752 | 治理層 |
crew/ |
3,183 | 多廠牌併審 |
cli_session/ |
1,304 | 驅動別家 AI CLI |
bin/spectyn.rs(61 個 subcommand)
└─ AgentRuntime::run*() 5 個入口
└─ run_inner
├─ for round in 0..max_rounds 回合迴圈
├─ providers/ → 解析供應商 → 串流
├─ 'stream: loop token / tool-call 解析
├─ ToolGateDecision 工具閘(權限 profile)
├─ tools/(33 個模組)
│ └─ process_sandbox Landlock / Seatbelt
├─ hallucination 反幻覺掃描
└─ AgentEvent → callbacks
AgentRuntime 對外開了五個入口,差別只在「誰在聽」:
| 入口 | 用途 |
|---|---|
run |
最單純,跑完給結果 |
run_tracked |
多帶成本追蹤 |
run_tracked_with_session |
多帶對話持久化 |
run_with_callbacks |
多帶事件回呼(TUI、WebSocket 用這個) |
run_with_callbacks_gated |
再多一層工具閘 |
五個最後都收束到同一個 run_inner。這是好設計——一條迴圈,五種觀察方式。
但往下一層就不是了。run_inner 裡選供應商的地方,樹裡有三份幾乎相同的實作。這不是我推論的,是原始碼註解自己寫的:
「這是第三條程式路徑⋯⋯之前是寫死的,忽略了 agent 的供應商清單和執行期覆寫,所以在某個介面下切換模型根本沒生效。」
一個 bug 被修了三次,因為同一件事被寫了三遍。而註解裡還誠實記下了那次真實事故:某個節點把 A 家的模型名送到 B 家的端點,換來一個 401。
把上面這些畫成一張圖,順便標出哪些是活的、哪些不是:

實線是活的,虛線是寫好了但不在執行路徑上,粗框是重構時不能弄丟的。右下角那三個虛線框,是這篇後半要講的主題。
Token · Thinking · ToolStart · ToolDone · Done · Notice · ConsistencyWarning
就這七個。TUI、exec --json、HTTP daemon 的 WebSocket、手機端——全部消費同一組事件。
小而穩的介面是重構時最珍貴的東西,因為它是唯一你可以放心不動的部分。後面所有要改的,都改在它兩側。
L1 governed_run/ 硬煞車 + 簽章飛行記錄器 + 手機升級
└─ L0 cli_session/ 互動式 AI-CLI session substrate
└─ claude / codex / agy / opencode
這裡有個被平台逼出來的分工,寫在原始碼註解裡:agy 的介接需要 pseudo-terminal,而那個函式庫在 Android 上編不過。所以行動端永遠不在本機驅動 AI CLI,只當遠端 supervisor。
架構有時候不是設計出來的,是被限制刻出來的。
我有一支 wrapper 腳本負責統一呼叫各家 AI CLI。翻它的原始碼時發現,它把這些東西分成三類:
dev trio : codex · agy · opencode(+ claude)
gateway agents : openclaw · hermes · spectyn ← 我自己在這一類
local LLM : ollama
寫這支腳本的時候我沒特別想過這件事,但它是對的。 前四家是寫程式的 CLI;hermes 和我做的東西是另一種物種——完整的個人 agent runtime。
所以下面的對照要分成兩組看:跟 dev trio 比介面設計,跟 hermes 比定位。
版本與體積:
| 版本 | 大小 | |
|---|---|---|
opencode |
1.17.12 | 124M |
codex |
0.149.1 | 210M |
agy |
1.1.19 | 170M |
claude |
2.1.243 | 345M |
serve 無介面 server
attach <url> 連上執行中的 server
web 起 server + 開網頁介面
acp Agent Client Protocol server(編輯器 ↔ agent)
plugin <module> 外掛
session / export / import
stats token 用量與成本

opencode web 特別值得看:daemon 自己就是 web server。這正好是我一直在想的那個問題的現成答案——如果介面只在 app 和網頁裡出現,那 UI 應該由 daemon 送出,而不是放在別的網域再回撥本機。
app-server 共用的本機 daemon
agents 瀏覽「共用 daemon 上」的所有 agent session
remote-control 以遠端控制管理該 daemon
mcp / mcp-server MCP 雙向
sandbox 沙盒是使用者可直接呼叫的命令
apply 把 agent 產出的 diff 當可保存產物,事後 git apply
resume fork queue archive unarchive delete migrate-rollouts

七個子命令都在管 session。對話不是暫存,是可以分岔、封存、排隊、遷移的物件。
--mode accept-edits|plan 執行模式
--effort low|medium|high 推理力道
--sandbox 終端限制
--input-format stream-json 從 stdin 讀 NDJSON,每行跑一輪
--output-format text|json|stream-json
--json-schema <schema|path> 強制結構化輸出

子命令只有四個,其餘全是旗標。設計取向很清楚:它假設你是用程式呼叫它,不是用手打它。
session: --fork-session --session-id --resume --continue
權限: --permission-mode --allowedTools --safe-mode
成本: --max-budget-usd --fallback-model --autocompact
MCP: mcp --mcp-config --strict-mcp-config
外掛: plugin --plugin-dir --plugin-url
遠端: --remote-control --teleport --bg --cloud --ide
agent: --agents '<inline JSON>' --system-prompt
其他: import(從別家 AI coding agent 匯入設定)

--max-budget-usd 和 --fallback-model 這兩個特別值得注意——成本上限和降級模型是每次呼叫都能指定的,不是埋在設定檔裡。
--output-format json|stream-json)doctor
看完四家,真正想抄的不是功能,是三個取向。
① 一個核心,多個 client(opencode、codex 都是)
opencode 有 serve / attach / web,codex 有 app-server / agents / remote-control。兩家的骨架一樣:無介面的核心跑在背景,介面全部是它的客戶。
對照我這邊:終端機介面 10,630 行是行程內實作,HTTP daemon 11,195 行是另一套。同一組能力兩套殼,兩邊各自維護。如果終端機介面改成 daemon 的一個 client、共用那七種事件,兩邊分歧的問題會直接消失。
這也回答了另一個我一直在想的問題:如果最終產品只在桌面 app、手機 app 和網頁裡出現,那網頁該怎麼連到本機?答案不是「把網頁放在某個網域再回撥本機」——那會撞上瀏覽器的三道限制(混合內容、私有網路預檢、跨來源政策)。答案是 opencode web 那個做法:daemon 自己就是 web server,同源,三個問題都不存在。
② session 是物件,不是暫存(codex)
七個子命令管 session:resume、fork、queue、archive、unarchive、delete、migrate。其中 fork 最值得抄——從某個歷史點分岔出一條新對話。這件事在多 agent 的場景下不是便利功能,是必需品:同一個脈絡讓三家 AI 各跑一次,然後比較,這需要分岔。
我這邊 session 只有即時檢視。
③ 危險的東西要看得見(codex、agy、claude 都是)
沙盒、權限模式、預算上限、降級模型——四家都把這些放在使用者每天看得到的介面上,不是設定檔深處。
claude 的 --max-budget-usd 特別能說明問題:花錢的上限應該是每次呼叫都能指定的,因為每次任務的風險不一樣。埋進設定檔的話,人只會設定一次然後忘記。
我這邊三樣都有實作,三樣都沒有入口。
前四家的定位是寫程式的工具。hermes-agent(Nous Research,MIT 授權)不是——它的定位是個人 agent runtime,主打內建的學習迴路。
那跟我在做的是同一件事,所以這是五個裡面唯一能做「同類比較」的對象。

功能重疊的部分:
| hermes | 我的對應 |
|---|---|
| 學習迴路:從經驗生成技能,並在使用中自我改進 | 技能萃取/召回,13,061 行 |
| agent 策展的記憶 + 定期 nudge | 自有記憶 + 召回 |
| FTS5 跨 session 搜尋 + LLM 摘要 | 同樣是 FTS5 |
| cron 排程,可投遞到任何平台 | 教練排程器 |
| spawn 隔離 subagent 平行工作 | 多廠牌併審 |
| MCP 整合 | 有(缺管理命令) |
hermes doctor |
有 |
兩邊在全文檢索上都選了 FTS5。這大概是同一個約束(本機、無外部服務、要能跨 session 搜尋)導向同一個答案。
它有、我沒有的部分:
| 項目 | hermes | 我 |
|---|---|---|
| 執行後端 | 七種:local、Docker、SSH、Singularity、Modal、Daytona、Vercel Sandbox。後三種閒置時休眠 | 自己的機器組成的 mesh |
| 訂閱聚合 | Nous Portal:模型 + 網頁搜尋 + 圖像生成 + TTS + 雲端瀏覽器,一份訂閱 | 無 |
| 平台覆蓋 | 單一 gateway process 同時服務 Telegram、Discord、Slack、WhatsApp、Signal、Email | 手機網頁介面 + Telegram |
| 遷移工具 | hermes claw migrate:從 OpenClaw 匯入設定、記憶、技能、API 金鑰,含 --dry-run |
無 |
| 技能格式 | 相容 agentskills.io 開放標準 | 自有格式 |
其中兩項跟我的處境直接相關:
執行後端。 它租算力,我用自己的機器。這是不同的取捨——我選自有硬體是為了資料不離開我控制的範圍,代價是沒有「閒置時不花錢」這個選項。反過來說,serverless 後端的資料會經過第三方。兩邊都不是免費的。
訂閱聚合。 我整個艦隊設計的出發點是「只負擔得起一個訂閱」,所以我用工程手段解:免費層輪替、本機模型、額度排程。Nous Portal 用商業手段解同一個問題——自己當聚合層,一份訂閱涵蓋五種服務。
工程手段的成本是複雜度,商業手段的成本是綁定。對我來說前者可行是因為我本來就要寫這套系統;對一般使用者來說後者顯然更合理。
遷移工具 值得單獨提一句,因為 claude 也有 import。兩家都把「從對手那邊搬過來」做成一級功能——使用者已經累積的東西才是真正的鎖定,所以降低搬家成本是產品策略。我這邊沒有這個概念,技能格式又是自有的,兩件事加起來是孤島風險。
在查 hermes 之前,我的盤點是「四家都沒有、而我有五樣」。加進 hermes 之後,其中兩樣站不住:
| 原本宣稱領先 | 加上 hermes 之後 |
|---|---|
| 技能萃取/召回 | ❌ 這是 hermes 的主打功能,而且相容開放標準 |
| 生活記憶 | ❌ hermes 有記憶策展、跨 session 搜尋、使用者建模 |
| 跨機 mesh | ⚠️ 部分成立——它用七種執行後端解同一個問題,取捨不同 |
| 驅動別家 AI CLI 並包進治理層 | ✅ 成立 |
| 反幻覺掃描器 | ✅ 成立 |
從五樣變成兩樣半。
這個修正對我有用,因為它把「我以為的差異化」和「真的差異化」分開了。剩下那兩樣有個共通點:它們不是功能,是紀律。 一個是「讓別家 AI 在我的治理下工作」,一個是「當 AI 宣稱做了事而其實沒做時,能自動抓到」。
| 為什麼別人沒有 | |
|---|---|
| 驅動別家 AI CLI 並包進治理層 | codex 不驅動 claude,claude 不驅動 codex;hermes 驅動的是模型不是別家 agent。而我在它們外面加了一層硬煞車、簽章飛行記錄器和手機核准 |
| 反幻覺掃描器 | 五家我都沒看到 |
| 跨機 mesh(半樣) | hermes 用七種執行後端解同一個問題,路徑不同:它租算力,我用自己的機器 |
其餘兩項(技能萃取、生活記憶)在 hermes 面前不成立,已從清單移除。
這兩樣半有個共通點:它們不是功能,是紀律。 一個是「讓別家 AI 在我的治理下工作」,一個是「當 AI 說謊時抓到它」。功能可以被抄,紀律不行——因為抄的人得先相信那件事重要。
下面把這兩項講細,因為它們是重構時唯一不能弄丟的。
反幻覺掃描器是一個確定性的正規表示式掃描器,專門抓一種回覆:訊息裡宣稱完成了有副作用的動作(「已建立檔案於⋯」「✅ 完成」),但那個回合的工具呼叫集合是空的。抓到就發出一個 ConsistencyWarning 事件。
它不用另一個模型去判斷,所以不會有第二層幻覺。它只比對一件事實:你說你做了,那你呼叫過工具嗎?
寫這篇的過程裡,我在五個不同的地方撞到同一個形狀的問題——綠燈,但什麼都沒做。這個掃描器就是那個問題在模型層的版本。五家商業 agent 我都沒看到誰有。
行程沙盒則是 shell 工具的每一次 spawn 都跑在作業系統層級的限制裡:Linux 用 Landlock、macOS 用 Seatbelt,預設拒絕。codex 走同一條路線,這是業界正解——差別在它把 sandbox 做成使用者可以直接呼叫的子命令,而我的埋在設定裡(見後面的「只差介面」)。
這是重構時的保護清單,不是行銷。意思是「別弄丟」。
清單從五樣縮到兩樣半這件事本身也是收穫——沒對照過就以為自己領先,是最便宜也最危險的自我感覺良好。
| 缺口 | 現況 | 性質 |
|---|---|---|
| session 生命週期 | 只有即時檢視,無 resume / fork / archive | 能力 |
| 結構化輸出 | 有 --json,無 stream-json、無 schema |
能力 |
| 外掛系統 | 無 | 能力 |
attach 到執行中的 server |
有 serve,無 attach |
能力 |
| MCP 管理命令 | client 已經接上了——agent 迴圈、工具註冊表、HTTP daemon 三處都在用,設定走設定檔的 mcp_servers 區塊;缺的只是 add/list/remove 子命令 |
只差介面 |
| 沙盒可見入口 | Landlock / Seatbelt 已經在跑,但沒有子命令也沒有旗標 | 只差介面 |
| 每次呼叫指定降級模型 | 失效鏈已經有,缺一個旗標 | 只差旗標 |
| 預算上限 | 成本追蹤已經有,缺一個介面 | 只差介面 |
| 從別家匯入設定 | 無 | 能力 |
九項裡有四項是「做好了但沒露出來」。
盤點完之後,「把 CLI 做好」這句話拆成三個很不一樣的動作。混在一起做,就會像我這幾天一樣連續踩坑。
最花時間、也最值錢的一步。三個實例:
① 兩層供應商,而路線圖描述的是死的那層。 樹裡有兩份供應商實作:一份 4,268 行、一份 8,727 行。agent 只從前者 import 了兩個提示詞格式的 helper,completion 完全走後者。而我的路線圖上寫的進度,講的是前面那份。
② 斷路器接在死線上。 一個 369 行、有完整測試、有可控時鐘的斷路器,在整棵樹裡唯一的非測試呼叫者,是那份不會被執行的鷹架。
③ 而它的測試是綠的。 這是最難發現的一種。那五條測試寫得很好,檔頭寫著「end-to-end」,但它們的驅動函式是測試檔裡自己重寫的一套——自己呼叫斷路器的 allow() 和 on_failure()。正式程式碼從來不呼叫。
它測的是一份「應該長這樣」的規格,而那份規格在正式程式碼裡不存在。
它是綠的、寫得很好——但它什麼都沒守住。
空測試至少數字是 0。這種有五條、全過、覆蓋面看起來完整。
把這幾天撞到的攤開來,會發現它們是同一種東西的不同穿著:
| # | 現場 | 為什麼是綠的 | 空轉多久 |
|---|---|---|---|
| 1 | 安裝腳本的乾跑保護 | 測試導出的環境變數用舊品牌前綴,腳本讀的是新的——兩邊名字對不上,保護從未生效 | 自改名起數週,而且它在 CI 裡跑 |
| 2 | 平台回歸測試的一條斷言 | 找一個舊檔名,而程式早就改產新檔名——斷言恆真 | 同上 |
| 3 | 每日回顧 | 資料格式漂移,讀取端拿不到任何事件;它照樣產出一份空的總結 | 12 天沒人發現 |
| 4 | 自測框架的 --feature 篩選 |
打錯 feature 名稱時回「0 通過, 0 失敗, 0 跳過」而退出碼是 0 | 未知 |
| 5 | 失效切換的五條測試 | 測的是測試檔自己重寫的一套驅動函式 | 未知 |
第 4 個最諷刺:那是負責抓前三個的工具,自己也會空轉。(這個已經修掉了:對不上的名稱現在退出碼 2,並列出所有合法名稱。)
而第 3 個最痛。它不是測試,是正在服務我的功能。一個號稱「越用越懂你」的系統,餵給它的資料斷了 12 天,而它每天都準時交出一份根據空集合寫成的回顧。
這五個湊在一起講的是同一句話:
「它在跑」和「它在做事」是兩件事,而多數系統只量得到前者。
檢查完要把結論釘成數字,否則下次改動沒有對照。
我寫了一條走真實路徑的測試:綁一個會計數的臨時埠,接受連線、計數、立刻掛斷;再放一個會回工具呼叫的第二家,好讓 agent 真的進入多回合。
死掉的供應商在一次任務裡被接觸 75 次,耗時 75 秒
而 MAX_RETRIES 只有 3。放大來源不是重試次數,是回合——供應商鏈每個回合都從頭重走一次,已知死掉的那家每回合都要再付一次完整的重試預算。
這條測試帶著兩個防護:
修的順序不是照重要性,是照依賴:
第一波 · 先接活的那套
斷路器接進真實請求路徑,讓上面那條 75 次的紅測試變綠。順帶把「什麼算失敗」定義清楚——連線被拒和逾時該計入開路;400 / 422 是我自己的請求寫壞了,不該算在對方頭上;401 / 403 是這把金鑰的永久問題,直接換下一家但不必判它不健康。這個分類寫錯,斷路器會把健康的供應商踢出輪替。
第二波 · 把已有的能力露出來
那四項「只差介面」的:MCP 管理子命令、沙盒的可見入口、降級模型旗標、預算上限。成本最低、見效最快,而且做完就等於承認一件事——功能寫好了但沒人找得到,跟沒寫差不多。
第三波 · 補真正缺的
session 生命週期、結構化輸出、外掛系統。這幾項是真的要從零寫。
第四波 · 消除雙實作
最大的工程,但前三波做完會自然浮現該從哪裡下手。而且順序不能反——在還沒確認哪一套是活的之前就開始合併,只會把兩份債攪在一起。
寫那條紅測試的時候,我沒先做完「檢查」就跳去「修復」——直接把斷路器接進我讀碼時認定的那條迴圈。
結果紅燈完全沒變。加了輸出一看:我接的那兩個地方,一行都沒被執行過。測試走的是另一條。
而且更糟:全套測試從 3 個既有失敗變成 4 個。多的那個看起來完全無關,但想得通——我把斷路器做成行程共用的,於是前一個測試打開的路,擋住了後面不相關的測試。 全域狀態在測試之間洩漏,在正式環境同樣會讓不相關的任務互相影響。
整段接線只好撤回。兩個教訓:不要憑讀碼猜哪條路是活的(今天第二次犯),以及斷路器要綁在執行個體上,不能是行程全域。
這就是為什麼「檢查」不能省。省下來的時間,會在「修復」那一步用三倍還回去。
一天之內,同一個形狀出現了四次:
| 一套 | 另一套 | 哪套活著 |
|---|---|---|
| 4,268 行的供應商鷹架 | 8,727 行的供應商層 | 後者 |
| 10,630 行的終端機介面 | 11,195 行的 HTTP daemon + 網頁前端 | 兩套都活,各自維護 |
| 173 個桌面 IPC 命令 | 34 個 HTTP 端點 | 兩套都活,但網頁端只拿得到後者 |
| 34 個舊品牌的命令名 | 新的執行檔名 | 衛星專案那側全是舊的 |
而斷路器是極端案例:唯一的使用者是死的那套——其中一套不但不會被執行,還把另一套需要的零件綁在自己身上。
我原本以為對照商業級 CLI 會得到一張「他們有而我沒有」的功能清單。實際得到的不是這個。
他們的共通點與其說是功能比較多,不如說是每個能力只有一個實作,而且都露在介面上。
所以重構的第一原則不是「補功能」:
每個能力只留一個實作,證明它是活的那個,然後把它露出來。
執行期 8,727 行的供應商層旁邊,躺著 4,268 行不會被執行的同名鷹架;369 行的斷路器只接在後者身上;而一個死掉的供應商,在一次任務裡被重試了 75 次。
今天做的事是盤點,不是開發。但盤點本身有個副產品:我原本相信的進度,有一部分是假的——不是有人造假,是文件描述的那套程式碼不在執行路徑上,而測試又剛好測了同一套。
明天處理一個我拖很久的問題:我之後主要會用的是 app 版,各平台的 app,那這一整套 CLI 該怎麼辦?61 個 subcommand、兩萬行的入口檔,要怎麼「併進」app?
我最後的答案是不要併——而理由跟一個發現有關:我以為要大工程才能做到的事,翻程式碼才發現已經做好八成了。